package cs236369.proxy;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;
import java.util.Scanner;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolVersion;
import org.apache.http.entity.BasicHttpEntity;
import org.apache.http.entity.BufferedHttpEntity;
import org.apache.http.impl.DefaultHttpClientConnection;
import org.apache.http.impl.DefaultHttpServerConnection;
import org.apache.http.message.BasicHttpRequest;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.util.EntityUtils;

/**
 * ProxyServer Class
 * Basic HTTP proxy server with cache and parental controll.
 * 
 * Submitters:
 * Ady Abraham 303112114 sadyabr@t2.
 * Gal Ben-Ami 066669946 sgalus@t2.
 */
public class ProxyServer {

	
	private int mPort;
	private ServerSocket mServerSocket;
	private String mDBServer_user;
	private String mDBServer_password;
	private String mDBServer_url;
	private String mDBServer_driver;
	private boolean mParental;
	private Connection mConnection = null;
    Statement mStatement = null;
    
    private enum DecisionTree {
    	HeadersNoCache,
    	MethodPost, 
    	URLNotInCache,
    	MethodGetAndURLInCache, 
    	CouldNotDecide;
    }

    
    /**
     * deletes an entry from the cache
     * @param urlEntry key for entry
     * @throws SQLException if entry not found
     */
    private void deleteFromCache(String urlEntry) throws SQLException {
    	mStatement.executeUpdate(
						   String.format(
						   "DELETE FROM Cache " +
						   "WHERE Cache.url='%s'", 
						   urlEntry)
					   );
		
		    	
    }
    
    /**
     * Returns a string respresenting Last Modified header 
     * @param urlEntry key for entry
     * @return a string respresenting Last Modified header
     * @throws SQLException if entry not found
     */
    private String getLastModifiedFromCache(String urlEntry) throws SQLException {
    	ResultSet cacheRes = null;

		cacheRes = mStatement.executeQuery(
						   String.format(
						   "SELECT lastmodified FROM Cache " +
						   "WHERE Cache.url='%s'", 
						   urlEntry)
					   );
		
		cacheRes.next();
		return cacheRes.getString("lastmodified");
    	
    }
    
    /**
     * Returns the corrensponding response to the request from the cache 
     * @param request
     * @return the corrensponding response to the request from the cache
     * @throws SQLException no entry that matches the given request
     */
    private HttpResponse getResponseFromCache(HttpRequest request) throws SQLException{
    	HttpResponse response = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), 200, null);
       	ResultSet cacheRes = null;

		cacheRes = mStatement.executeQuery(
							   String.format(
							   "SELECT * FROM Cache " +
							   "WHERE Cache.url='%s'", 
							   request.getRequestLine().getUri())
						   );
			
		cacheRes.next();	
		Scanner scanner = new Scanner(cacheRes.getString("headers"));
    	while (scanner.hasNextLine()){
    		String name = scanner.findInLine("[^:]*").trim();
    		
    		String value = scanner.findInLine("[:](.*)").replaceFirst(":", "").trim();
    		response.addHeader(name, value);
    		scanner.nextLine();
    	}
    	
		BasicHttpEntity entity = new BasicHttpEntity();
		entity.setContent(cacheRes.getBlob("Content").getBinaryStream());
		response.setEntity(entity);
    	
    	return response;
    }
    
    /**
     * Returns a new HttpResponse that is same as the given response without <img...> tags
     * @param response
     * @return a new HttpResponse
     * @throws IOException
     */
    private HttpResponse parentControll(HttpResponse response) throws IOException{
    	InputStream is = null;
    	Header contentType = response.getFirstHeader("Content-Type");
    	
    	//guarantee that the content type is of type text/html
    	//otherwise return the response without changing it
    	if (contentType == null || !contentType.getValue().contains("text/html")){
    		return response;
    	}

    	InputStream contentIS = null;
		try {
	    	contentIS = response.getEntity().getContent();
		} catch (IOException e1) {
			System.err.println("ERROE: Can't read response entity");
			throw e1;
		}
    	StringBuilder contentStringBuilder = new StringBuilder();
		
		char[] buffer = new char[1024];
		Reader reader = null;
		try {
			reader = new BufferedReader( new InputStreamReader(contentIS, "UTF-8"));
		} catch (UnsupportedEncodingException e1) {}
			
		int n;
		try {
			while ((n = reader.read(buffer)) != -1) {
				contentStringBuilder.append(buffer, 0, n);
			}
		} catch (IOException e1) {
			System.err.println("ERROR: A buffer read error occured when reading");
			throw e1;
		}
		
		try {
			String contentString = contentStringBuilder.toString();
			contentString = contentString.replaceAll("<\\s*img[^>]*>", "");
			is = new ByteArrayInputStream(contentString.getBytes("UTF-8"));
		} catch (UnsupportedEncodingException e) {}
		BasicHttpEntity entity = new BasicHttpEntity();
		entity.setContent(is);
		EntityUtils.consume(response.getEntity());
		response.setEntity(entity);
		return response;
    }
     
    /**
     * inserts a pair of request/response into the cache
     * @param request
     * @param response
     * @throws IOException
     * @throws SQLException if the request has already an entry in the cache 
     */
    private void insertIntoCacheTable(HttpRequest request, HttpResponse response) throws IOException, SQLException{
    	String headers = "";
    	String lastModified = "";
    	
    	
    	for (Header head : response.getAllHeaders()){
    		headers += head.toString() + "\r\n";
    	}

    	
    	Header lastModifiedHeader = response.getFirstHeader("Last-Modified");
    	if (lastModifiedHeader != null){
    		lastModified = response.getFirstHeader("Last-Modified").getValue();
    	}
    	
    	BufferedHttpEntity bufferedEntity = new BufferedHttpEntity(response.getEntity());
    	response.setEntity(bufferedEntity);

    	InputStream content = bufferedEntity.getContent();
    	
    	String cacheSql = "INSERT INTO Cache (url, headers, content, lastmodified) " +
    					  "VALUES(?,?,?,?)";
    	java.sql.PreparedStatement stmnt=null;
    	stmnt = mConnection.prepareStatement(cacheSql);
		
    	String url = request.getRequestLine().getUri();
		
		stmnt.setString(1, url);
		stmnt.setString(2, headers);
		stmnt.setBlob(3, content);
		stmnt.setString(4, lastModified);
		stmnt.executeUpdate();
		
    
    }
    
    /**
     * check if a response meets the caching conditions in the assignment.
     * @param response
     * @return
     */
   private  boolean meetCacheConditions(HttpRequest request, HttpResponse response){
    	if (response.getStatusLine().getStatusCode() != 200){
    		return false;
    	}
    	Header[] headers = response.getHeaders("Cache-Control");
		for (Header head : headers){
			if (head.getValue().equals("no-cache") || head.getValue().equals("no-store")){
				return false;
			}
		}
    	
		Header lastModifiedHeader = response.getFirstHeader("Last-Modified");
    	if (lastModifiedHeader == null){
    		return false;
    	}
    	String lastModified = response.getFirstHeader("Last-Modified").getValue();
    	
    	String url = request.getRequestLine().getUri();
		String allHeaders = "";
		
		for (Header head : response.getAllHeaders()){
			allHeaders += head.toString() + "\r\n";
    	}
		
		Header contentLen = response.getFirstHeader("Content-Length");
    	if (contentLen == null){
    		System.out.println("LOG: not caching, Content-Length header is not present");
    		return false;
    	}
    	
    	int contentSize = Integer.parseInt(contentLen.getValue());
		
		if(url.length() > 512 || allHeaders.getBytes().length > 65535 || contentSize > 65535 || lastModified.length() > 256){
			System.out.println("LOG: not caching, some atributes are too large");
			return false;
		}
		
		return true;
    }
	    
	/**
	 * According to the decision tree described in the assignment
	 * this method determines the place in the tree.
	 * @param request
	 * @return
	 * @throws SQLException 
	 */
   private DecisionTree decisionTree(HttpRequest request) throws SQLException {
		String method = request.getRequestLine().getMethod();
		if (method.equals("POST")){
			return DecisionTree.MethodPost;
		}
		Header[] headers = request.getHeaders("Cache-Control");
		for (Header head : headers){
			if (head.getValue().equals("no-cache") || head.getValue().equals("no-store")){
				return DecisionTree.HeadersNoCache;
			}
		}
		if (!isCached(request)){
			return DecisionTree.URLNotInCache;
		}
		if (method.equals("GET")){
			return DecisionTree.MethodGetAndURLInCache;
		}
		return DecisionTree.CouldNotDecide;
   }

    /**
     * check if the response for the given request is cached in the system. 
     * @param request
     * @return true if cached.
     * @throws SQLException 
     */
   private boolean isCached(HttpRequest request) throws SQLException {
	   ResultSet cacheRes = mStatement.executeQuery(
					         String.format(
						     "SELECT 1 FROM Cache " +
						     "WHERE Cache.url='%s'", 
						     request.getRequestLine().getUri())
					         );
		
	   return cacheRes.next();
    }
			
	/**
	 * Contructor.
	 * @param p propery for ProxyCache instanse.
	 * @throws IOException
	 * @throws ClassNotFoundException
	 * @throws SQLException 
	 */
	public ProxyServer(Properties p) throws IOException, ClassNotFoundException, SQLException  {
		
		// read properties
		
		mDBServer_user = p.getProperty("DBServer.user");
		mDBServer_password = p.getProperty("DBServer.password");
		mDBServer_url = p.getProperty("DBServer.url");
		mDBServer_driver = p.getProperty("DBServer.driver");
		mParental = Boolean.parseBoolean(p.getProperty("proxy.parental"));
		mPort = Integer.parseInt(p.getProperty("proxy.port"));
		
		mServerSocket = new ServerSocket(mPort);
		
		// connect to database
				
		// Load the Driver class. 
		Class.forName(mDBServer_driver); 
		 
        //Create the connection using the static getConnection method 
		mConnection = DriverManager.getConnection (mDBServer_url, mDBServer_user, mDBServer_password); 

		//Create a Statement class to execute the SQL statement 
		mStatement = mConnection.createStatement(); 
	}
		
	
	/**
	 * Creates a new table called 'Cache' to be used as the proxy cache
	 * 
	 * @throws SQLException if table already exists
	 */
	public void createCacheTable() throws SQLException {
				
		//Execute the SQL statement 
		mStatement.executeUpdate("CREATE TABLE Cache "    + 
				           "(url VARCHAR(512), "          + 
			           	   "headers TEXT, "               + 
	                       "content BLOB, "               + 
	                       "lastmodified VARCHAR(256), "  + 
	                       "PRIMARY KEY(url));");
	}
		
	/**
	 * start listening on the port specified in the propery (given in Ctor),
	 * and starts the proxy server.
	 */
	public void start() {
		
		while (true) {
			try {
				Socket con = mServerSocket.accept();
				doProxyWork(con);
				con.close();
				
			} 
			catch (IOException e) {
				System.err.println("ERROR: IO error: " + e.getMessage());
				e.printStackTrace();
			} catch (HttpException e) {
				System.err.println("ERROR: HTTP error: " + e.getMessage());
				e.printStackTrace();
			} catch (SQLException e) {
				System.err.println("ERROR: SQL error: " + e.getMessage());
				e.printStackTrace();
			}
			
		}
			
	}
	
	
	/**
	 * Deletes all entries from the cache
	 * @throws SQLException if cache table doesn't exist
	 */
	public void cacheClear() throws SQLException {
		mStatement.executeUpdate("DELETE FROM Cache");
	}
	
	
	/**
	 * Main proxy function,
	 * getting a request from client and sends it to the server, and vice versa.
	 * Also does caching and/or parental control feature.
	 * 
	 * @param socketToClient an connected socket to the client
	 * @throws IOException
	 * @throws HttpException 
	 * @throws SQLException 
	 */
	private void doProxyWork(Socket socketToClient) throws IOException, HttpException, SQLException {
		
		HttpParams params = new BasicHttpParams(); 
		
		DefaultHttpServerConnection connToClient = new DefaultHttpServerConnection();
		connToClient.bind(socketToClient, params);
		HttpRequest request = null;
		
		request = connToClient.receiveRequestHeader();
		System.out.println("LOG: Got request: " + request.getRequestLine().toString());
		
		HttpEntity requestEntity = null;
		if (request instanceof HttpEntityEnclosingRequest) {
            connToClient.receiveRequestEntity((HttpEntityEnclosingRequest) request);
            requestEntity = ((HttpEntityEnclosingRequest) request).getEntity();
        }

		// remove Accept-Encoding header, if any
		request.removeHeaders("Accept-Encoding");
		
		URL hostURL = hostToURL(request.getFirstHeader("Host").getValue());
		Socket socketToServer = new Socket(hostURL.getHost(), hostURL.getPort() == -1 ? 80 : hostURL.getPort());
		System.out.println("LOG: Connecting to " + hostURL.getHost() + ":" + (hostURL.getPort() == -1 ? 80 : hostURL.getPort()));
		DefaultHttpClientConnection connToServer = new DefaultHttpClientConnection();
		connToServer.bind(socketToServer, params);
		
		DecisionTree decision = decisionTree(request);
		HttpResponse response = null;
		boolean bSendRequestToServer = false;
		
		switch (decision) {
			case MethodPost:
			case URLNotInCache:
			case HeadersNoCache:
				System.out.println("LOG: Getting content from server (not from cache)");
				deleteFromCache(request.getRequestLine().getUri());
				bSendRequestToServer = true;
				break;
				
			case MethodGetAndURLInCache:
				System.out.println("LOG: entry in cache");				
				String lastModified = null;
				try {
					lastModified = getLastModifiedFromCache(request.getRequestLine().getUri());
				} catch (SQLException e1) {}
				
				if (lastModified == null || lastModified.equals("")) {
					bSendRequestToServer = true;
					break;
				}
			
				System.out.println("LOG: cheching if entry is fresh");
				HttpRequest requestValidation = new BasicHttpRequest(request.getRequestLine());
				requestValidation.setHeader(request.getFirstHeader("Host"));
				requestValidation.setHeader("Connection", "close");
				requestValidation.setHeader("If-Modified-Since", lastModified);
							
				connToServer.sendRequestHeader(requestValidation);
				connToServer.flush();
				
				response = connToServer.receiveResponseHeader();
								
				if (response.getStatusLine().getStatusCode() == 304) {
					System.out.println("LOG: entry is fresh");
					bSendRequestToServer = false;
					break;
				}
				
				if (response.getStatusLine().getStatusCode() == 200) {
					System.out.println("LOG: entry is not fresh, updating");
					connToServer.receiveResponseEntity(response);
					
					deleteFromCache(request.getRequestLine().getUri());
					insertIntoCacheTable(request, response);
					break;
				}
				
				System.out.println("LOG: entry is not fresh, deleting from cache");
				deleteFromCache(request.getRequestLine().getUri());
				break;
			default:
				System.out.println("LOG: decition tree could not decide, request from server");
				bSendRequestToServer = true;
				break;		
		}
				
		if (bSendRequestToServer || !isCached(request)) {
			System.out.println("LOG: sending request to server");
			connToServer.sendRequestHeader(request);
			if (requestEntity != null) {
				connToServer.sendRequestEntity((HttpEntityEnclosingRequest) request);
	            EntityUtils.consume(requestEntity);
	        }
			connToServer.flush();
			
			response = connToServer.receiveResponseHeader();
			connToServer.receiveResponseEntity(response);
						
			if ((meetCacheConditions(request, response)) && decision != DecisionTree.MethodPost) {
				System.out.println("LOG: save request/response in cache");
				insertIntoCacheTable(request, response);
			}
			else {
				System.out.println("LOG: not saving in cache");
			}
		}
		else {
			System.out.println("LOG: getting response from cache");
			response = getResponseFromCache(request);
		}
				
		if (mParental) {
			parentControll(response);
		}
						
		connToClient.sendResponseHeader(response);
			
		HttpEntity entity = response.getEntity();
		if (entity != null) {
		    connToClient.sendResponseEntity(response);
			EntityUtils.consume(entity);
		}
		
		connToServer.shutdown();
		socketToServer.close();
		connToClient.shutdown();
	}
	
	/**
	 * Creates a new URL from given string
	 * @param host
	 * @return
	 * @throws MalformedURLException
	 */
	private URL hostToURL(String host) throws MalformedURLException{
		//  if protocol not specified, use http
		if (!host.contains("://")) {
			host = "http://" + host;
		}
		
		return new URL(host);
	}
	
	
	public static void main(String[] args) throws Exception {
		Properties p = new Properties();
		p.load(new FileInputStream("config"));
		ProxyServer proxy = new ProxyServer(p);
		//proxy.createCacheTable();
		proxy.start();
	}

}
